Skip to content

feat(cli): add me import granola for meeting notes & transcripts#91

Open
murrayju wants to merge 2 commits into
mainfrom
murrayju/import-granola
Open

feat(cli): add me import granola for meeting notes & transcripts#91
murrayju wants to merge 2 commits into
mainfrom
murrayju/import-granola

Conversation

@murrayju

Copy link
Copy Markdown
Member

Summary

Adds me import granola — import Granola meetings (AI summary notes + transcripts) into the memory engine, one memory per meeting.

There is no separate Granola login. The importer reuses the Granola desktop app's existing session by reading its locally-stored, safeStorage-encrypted WorkOS tokens (decrypting them via the macOS login keychain), refreshing the short-lived access token through Granola's API, then pulling meetings.

me import granola --dry-run            # preview everything Granola has
me import granola                      # full import (notes + transcripts) into ~/granola
me import granola --no-transcript      # notes only (faster; fewer API calls)
me import granola --since 2026-01-01   # just this year's meetings

How it works

  • Auth (auth.ts) — reproduces Granola's two-layer Electron safeStorage scheme entirely from local material: keychain password → PBKDF2 → AES-128-CBC decrypt of storage.dek → 32-byte data key → AES-256-GCM decrypt of supabase.json.enc. Throws actionable errors when Granola isn't installed/signed in, on non-macOS, or if the format changes.
  • API client (client.ts) — refreshes the access token (/v1/refresh-access-token), then pulls meetings (/v2/get-documents, offset-paged), AI summary panels (/v1/get-document-panels), and transcripts (/v1/get-document-transcript). Sends the required X-Client-Version header.
  • Render (render.ts) — one self-contained Markdown memory per meeting: title heading, date/attendees line, AI notes, and (default-on) the transcript grouped into speaker turns (Me = your mic, Them = everyone else; Granola doesn't attribute remote speakers). Notes prefer the meeting's own notes_markdown, then a panel's structured ProseMirror content (keeps nested-list indentation), then its HTML.
  • Orchestration (index.ts) — lists meetings, pre-filters (deleted / invalid / since / until / empty), fetches notes+transcript per meeting, submits via batchCreate with onConflict: "replace".

Idempotency

Keyed on (tree, name) where name is the Granola document_id. Re-imports reconcile in place via the server's content-aware replace (stamped meta.importer_version): an unchanged meeting is a no-op, a changed one is rewritten, nothing is duplicated.

meta.display_name (web tree labels)

Because the leaf name is the document UUID (to key idempotency), the web tree would otherwise show a UUID. This PR adds a generic meta.display_name presentation hint: the web tree's memoryToLeaf now prefers meta.display_namename → first content line → id tail. The Granola importer stamps "Title — YYYY-MM-DD", so a recurring meeting title stays distinguishable by date. This is not Granola-specific — any memory can set it.

Tree layout

<tree-root>/<document_id>     # default tree-root: ~/granola (private to you)

Tests

  • auth.test.ts — synthesizes the storage.dek + supabase.json.enc pair in reverse from a known password and asserts the decrypt chain recovers tokens; covers missing/corrupt material and the non-macOS guard.
  • render.test.ts — HTML→MD and ProseMirror→MD conversion, notes-source precedence, transcript speaker grouping, display_name formatting.
  • index.test.ts — pre-filtering, per-meeting fetch gating, empty-meeting skip, dry-run, batch submission (fake source + in-memory engine; no network/DB).
  • tree-build.test.tsdisplay_name precedence + blank/non-string guard.

All suites green (./bun run check: 671 pass). Verified end-to-end against real Granola data (311 meetings, 309 importable; idempotent re-import confirmed).

Notes / limitations

  • macOS only for now — the credential read shells out to the login keychain. Linux/Windows would need the corresponding secret-store backends.
  • Requires the Granola desktop app installed and signed in on the machine running the import.
  • The pre-existing web typecheck errors in MonacoMarkdownEditor.tsx are unrelated (already on main; packages/web is excluded from the root typecheck).

@murrayju murrayju requested a review from jgpruitt as a code owner June 23, 2026 21:08
Import Granola meetings (AI summary notes + transcripts) into the memory
engine, one memory per meeting. Reuses the Granola desktop app's existing
session: reads its locally-stored, safeStorage-encrypted WorkOS tokens
(decrypted via the macOS login keychain), refreshes the access token, and
pulls meetings from the Granola API.

- Idempotent on (tree, document_id) via content-aware `replace`; re-imports
  reconcile in place, never duplicate.
- One self-contained Markdown memory per meeting: title, date/attendees,
  notes, and (default-on) speaker-grouped transcript.
- Adds a generic `meta.display_name` presentation hint the web tree prefers
  over `name`/content, so Granola leaves show "Title — date" instead of the
  document-id `name` that keys idempotency.

macOS-only for now (credential read uses the login keychain).
@murrayju murrayju force-pushed the murrayju/import-granola branch from 7337686 to 6f403bb Compare June 25, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant